Published on

A TicTacToe Game Written in Kubernetes Operator - Part 2

Authors

We will continue to build the TicTacToe game in this article. In the previous article, we have created the CRD and the controller. In this article, we will add some fields to the CRD and implement the logic of the game.

The source code of this article is available at earayu/tictactoe-operator

How to Play The TicTacToe Game

Well, Since we are implementing the TicTacToe game in Kubernetes, we will play the game through YAML files.

To start a new game, we need to create a TicTacToe CR.

apiVersion: earayu.github.io.earayu.github.io/v1alpha1
kind: TicTacToe
metadata:
  name: tictactoe-game
spec:

To make a move, we need to create a Move CR.

apiVersion: earayu.github.io.earayu.github.io/v1alpha1
kind: Move
metadata:
  name: move-1
spec:
  ticTacToeName: tictactoe-game
  row: 1
  column: 1

To check the status of the game, we need to get the TicTacToe CR.

$ kubectl get TicTacToe tictactoe-game -o yaml

apiVersion: earayu.github.io.earayu.github.io/v1alpha1
kind: TicTacToe
metadata:
  annotations:
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"earayu.github.io.earayu.github.io/v1alpha1","kind":"TicTacToe","metadata":{"annotations":{},"name":"tictactoe-game","namespace":"default"},"spec":null}
  creationTimestamp: "2024-01-13T17:02:27Z"
  generation: 1
  name: tictactoe-game
  namespace: default
  resourceVersion: "909293"
  uid: abe84d82-a64d-46f1-aa2a-d13fdd4d0edf
status:
  chessboard1: ' - | - | - '
  chessboard2: ' X | O | - '
  chessboard3: ' - | - | - '
  move:
    items:
    - apiVersion: earayu.github.io.earayu.github.io/v1alpha1
      kind: Move
      metadata: {}
      spec:
        column: 1
        player: 1
        row: 1
        ticTacToeName: tictactoe-game
      status:
        state: Processing
    - apiVersion: earayu.github.io.earayu.github.io/v1alpha1
      kind: Move
      metadata: {}
      spec:
        player: 2
        row: 1
        ticTacToeName: tictactoe-game
      status:
        state: Processing
    metadata: {}
  state: playing

Did you notice that the TicTacToe CR has two related Move CRs?

The first one is the move of the human player, created by us in the YAML file. And the second one is the move of the bot player, created by the controller.

Modify the CRD

So, Let's start to implement the game.
We will start by adding some fields to the Move and TicTacToe CRD. To do this, we need to modify the api/v1alpha1/move_types.go and api/v1alpha1/tictactoe_types.go file.

// MoveSpec defines the desired state of Move
type MoveSpec struct {
	// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
	// Important: Run "make" to regenerate code after modifying this file

	TicTacToeName string `json:"ticTacToeName"`

	//+kubebuilder:default=1
	Player int `json:"player,omitempty"`
	Row    int `json:"row,omitempty"`
	Column int `json:"column,omitempty"`
}
// TicTacToeStatus defines the observed state of TicTacToe
type TicTacToeStatus struct {
	// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
	// Important: Run "make" to regenerate code after modifying this file

	MoveHistory MoveList `json:"move,omitempty"`

	Chessboard1 string `json:"chessboard1,omitempty"`
	Chessboard2 string `json:"chessboard2,omitempty"`
	Chessboard3 string `json:"chessboard3,omitempty"`

	// State: playing,draw,humanWins,botWins
	State string `json:"state,omitempty"`

	// default current time now
	Version metav1.Time `json:"timestamp,omitempty"`
}

After modifying the CRD, we need to run make to regenerate the code.

make manifests
make generate
make install

Implement the Game Logic

We will implement the game logic in the internal/controller/move_controller.go and internal/controller/tictactoe_controller.go file.

The Move Controller

kubebuilder has already generated the MoveReconciler for us. All we need to do is to implement the Reconcile function.

The method takes two parameters: ctx, a context object for carrying deadlines, cancellations, and other request-scoped values across API boundaries and between processes, and req, a ctrl.Request object which contains the information of the reconciled object.

func (r *MoveReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {

The first part of the method retrieves a Move object from the Kubernetes API server using the namespaced name from the request. If the Move object is not found, it returns a reconcile.Result with no error.

move := earayugithubiov1alpha1.Move{}
if err := r.Get(ctx, req.NamespacedName, &move); err != nil {
  return reconcile.Result{}, client.IgnoreNotFound(err)
}

Next, it retrieves a TicTacToe object using the TicTacToeName from the Move object. If the TicTacToe object is not found, it also returns a reconcile.Result with no error.

ticTacToe := earayugithubiov1alpha1.TicTacToe{}
if err := r.Get(ctx, namespacedName, &ticTacToe); err != nil {
  return reconcile.Result{}, client.IgnoreNotFound(err)
}

The method then sets the TicTacToe object as the owner of the Move object. If it fails to set the owner reference, it logs the error and returns a ctrl.Result with the error.

if err := controllerutil.SetControllerReference(&ticTacToe, &move, r.Scheme); err != nil {
  l.Error(err, "unable to set controller reference")
  return ctrl.Result{}, err
}

The method then updates the Move object in the Kubernetes API server. If it fails to update, it logs the error and returns a ctrl.Result with the error.

if err := r.Update(ctx, &move); err != nil {
  l.Error(err, "unable to update Move status")
  return ctrl.Result{}, err
}

The method then checks the state of the Move object. If the state is Duplicate or NotAllowed, it returns a reconcile.Result with no error, effectively ignoring these resources.

if move.Status.State == earayugithubiov1alpha1.Duplicate || move.Status.State == earayugithubiov1alpha1.NotAllowed {
  return reconcile.Result{}, nil
}

Finally, the method validates the row and column specified in the Move object. If they are out of bounds, it sets the state of the Move object to NotAllowed, updates the Move object in the Kubernetes API server, and returns a reconcile.Result with no error.

if move.Spec.Row < 0 || move.Spec.Row >= 3 || move.Spec.Column < 0 || move.Spec.Column >= 3 {
  move.Status.State = earayugithubiov1alpha1.NotAllowed
  if err := r.Status().Update(ctx, &move); err != nil {
   l.Error(err, "unable to update Move status")
   return ctrl.Result{}, err
  }
  return reconcile.Result{}, nil
}

In summary, the Reconcile method is responsible for ensuring that the state of the Move object in the Kubernetes API server matches the desired state specified by the user. It does this by retrieving the Move and TicTacToe objects, setting the owner reference, updating the Move object, and validating the row and column of the Move object.

The TicTacToe Controller

Just Like the MoveReconciler, kubebuilder has already generated the TicTacToeReconciler for us. All we need to do is to implement the Reconcile function.

The method begins by retrieving a TicTacToe object from the Kubernetes API server using the namespaced name from the request. If the TicTacToe object is not found, it returns a reconcile.Result with no error.

var ticTacToe earayugithubiov1alpha1.TicTacToe
if err := r.Get(ctx, req.NamespacedName, &ticTacToe); err != nil {
  return reconcile.Result{}, client.IgnoreNotFound(err)
}

Next, it lists all Move objects that have a .spec.ticTacToeName field matching the TicTacToe object's name. It then filters these moves to only include those that are in the Processing state or have an empty state.

var allMoveList earayugithubiov1alpha1.MoveList
if err := r.List(ctx, &allMoveList, client.InNamespace(req.Namespace), client.MatchingFields{ticTacToeOwnerKey: req.Name}); err != nil {
  return reconcile.Result{}, fmt.Errorf("list moves err:%w", err)
}

The method then sorts the Move objects by their creation time and processes each move. It checks if the move is invalid or a duplicate, and updates the move's state accordingly. If an error occurs during this process, it returns a reconcile.Result with the error.

sort.Slice(processingMoves, func(i, j int) bool {
  return processingMoves[i].CreationTimestamp.Before(&processingMoves[j].CreationTimestamp)
})

After processing the moves, the method retrieves the current state of the game board and checks for a winner. If a winner is found, it updates the TicTacToe object's state to reflect the winner. If the game is finished but there is no winner, it sets the state to "draw". If the game is not finished, it sets the state to "playing".

winner, finished := portable.CheckWinner(board)

If the game is still in progress, the method checks if it's the bot's turn to make a move. If it is, the bot makes a random move, and the method creates a new Move object for the bot's move and adds it to the Kubernetes API server. If an error occurs during this process, it returns a reconcile.Result with the error.

if ticTacToe.Status.State == "playing" {
  nextPlayer := portable.NextPlayer(&ticTacToe.Status.MoveHistory)
  if nextPlayer == earayugithubiov1alpha1.Bot {
   row, col, hasMoved := portable.RandomMove(board)
   if hasMoved {
    botMove := &earayugithubiov1alpha1.Move{
     ObjectMeta: metav1.ObjectMeta{
      Name:      fmt.Sprintf("%s-bot-move-%d-%d", ticTacToe.Name, row, col),
      Namespace: ticTacToe.Namespace,
     },
     Spec: earayugithubiov1alpha1.MoveSpec{
      TicTacToeName: ticTacToe.Name,
      Player:        earayugithubiov1alpha1.Bot,
      Row:           row,
      Column:        col,
     },
    }
    controllerutil.SetControllerReference(&ticTacToe, botMove, r.Scheme)
    err := r.Create(ctx, botMove)
    if err != nil {
     return ctrl.Result{}, fmt.Errorf("create move err:%w", err)
    }
    return ctrl.Result{Requeue: true}, nil
   }
  }
 }

TicTacToe Reconcile Method calls some functions from the portable package. we will not cover the portable package in this article. The source code of the portable package is available at earayu/tictactoe-operator/internal/controller/portable

Run/Debug the Operator

The easiest way to run the operator is to use the make run command. This command will build the operator and run it locally.

make deploy

As of Debugging, we can use IDE's debug feature to debug the "cmd/main.go" file. Just like debugging a normal Go program.

As of Now, we can play the game by creating the TicTacToe and Move CRs. Just like the YAML files we mentioned above.